Skip to content

feat(otel): add actor lifecycle + scheduler duration metrics - #514

Merged
Jeff Luo (JeffLuoo) merged 5 commits into
agent-substrate:mainfrom
krisztianfekete:feat/add-lifecycle-and-scheudler-metrics
Aug 4, 2026
Merged

feat(otel): add actor lifecycle + scheduler duration metrics#514
Jeff Luo (JeffLuoo) merged 5 commits into
agent-substrate:mainfrom
krisztianfekete:feat/add-lifecycle-and-scheudler-metrics

Conversation

@krisztianfekete

@krisztianfekete Krisztian F (krisztianfekete) commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

This PR is the second slice of the platform-metrics split (#433).

It adds two duration histograms emitted by ateapi:

  • ate.actor.lifecycle.operation.duration: create/resume/suspend/pause/delete, labeled by operation, template, pool, sandbox class, and (on resume) snapshot kind
    • I'd like to use this for user-facing latency for e.g. showing when suspended actors can serve requests again. The existing rpc.server.call.duration metric covers this, but the meaningful dimensions are missing, so it's not really actionable. Extending its labels with extra, domain-specific labels is an OTel anti-pattern, hence the new metric.
  • ate.scheduler.assignment.duration: worker-assignment step, labeled by outcome (assigned / no_free_worker / error) and pool
    • I'd like to use this to alert on no free worker situation, and have proper SLOs via various percentiles for assigning latencies. There's no RPC around this, so it's not something existing RPC metrics cover.

Tested e2e on a local kind cluste where: both metrics reach the otel-system collector with the expected labels (resume shows snapshot_kind=golden, scheduler shows outcome=assigned, no error.type on success).

  • Tests pass
  • Appropriate changes to documentation are included in the PR

@krisztianfekete
Krisztian F (krisztianfekete) marked this pull request as ready for review July 24, 2026 11:06

func (s *Service) CreateActor(ctx context.Context, req *ateapipb.CreateActorRequest) (*ateapipb.Actor, error) {
if err := validateCreateActorRequest(req); err != nil {
func (s *Service) CreateActor(ctx context.Context, req *ateapipb.CreateActorRequest) (created *ateapipb.Actor, err error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we swapping everything to be named return args, that's not a very common pattern. If you're trying to capture err can you just do var err error. I don't feel super duper strongly about this, but I personally find it a bit harder to read.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's intentional, because with a var err error the defer would capture the pre-mapping error and also misqualify error.type. Would you say a comment would make this clearer?

Comment on lines +38 to +41
s.instruments.recordLifecycleOp(ctx, ateattr.OperationDelete, start, err,
ateattr.TemplateNameKey.String(tmpl.GetActorTemplateName()),
ateattr.TemplateNamespaceKey.String(tmpl.GetActorTemplateNamespace()),
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think the behavior of this might be confusing. If this method returns before tmpl is assigned it may create a record with empty name, namespace. I think it's better to just use the incoming values even if they don't exist, what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, although DeleteActorRequest is an ObjectRef so there's no template. I'll just drop template labels from delete entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped this one.

// A nil *Instruments is a valid no-op, so call sites need no guard. Worker-count
// is registered separately (RegisterWorkerCount): a callback-driven observable,
// not a synchronous instrument.
type Instruments struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason this is public, seems like we could construct it locally rather than exporting it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's exported because main.go builds it and passes it to NewService like e.g. RegisterWorkerCount, so this seemed like the consistent way.

@krisztianfekete

Copy link
Copy Markdown
Contributor Author

cc. Da Huang (@git286), Julian Gutierrez Oschmann (@juli4n) for review. This has been up for a good while now, can you please review when you have a chance?

@zoez7

Copy link
Copy Markdown
Collaborator

Comment on lines +181 to +188
// Recorded before the lock so lock contention still counts as an attempt; the
// incoming ctx stays valid where acquireActorLock returns nil on failure.
defer func() {
w.instruments.recordLifecycleOp(ctx, ateattr.OperationResume, start, err,
lifecycleOpAttrs(state.Actor, state.ActorTemplate, state.SnapshotKind)...)
}()

lockCtx, lock, err := w.acquireActorLock(ctx, actorRef)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResumeActor is called on every event, and ActorResumer has no local "already running" cache — it always issues the ResumeActor RPC. That's exactly why ResumeState.WasRunning and the Resumed response field exist (ResumeOutcomeNone = "actor already running" in the router's own docs table).

This defer records unconditionally, so the sample rate of ate.actor.lifecycle.operation.duration{operation="resume"} equals router QPS, and the population is dominated by sub-millisecond no-op resumes that fast-forward every step. The p50/p99 you'd read off this is the warm-path no-op latency, not "when a suspended actor can serve requests again" — which is not the stated goal of the metric.

Consider adding a check here:

       if err == nil && state.WasRunning {
           return // already running: not a resume, and the router calls this per request
       }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added your guard in the latest push + a test pinning it.

actorTemplateLister listersv1alpha1.ActorTemplateLister
workerPoolLister listersv1alpha1.WorkerPoolLister
actorWorkflow *ActorWorkflow
instruments *Instruments

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instrument is threaded into two different layers:

  • create / delete are recorded in the gRPC handler (create_actor.go, delete_actor.go), so they include request validation, lister lookups, and the final gRPC status.
  • resume / suspend / pause are recorded inside ActorWorkflow (workflow.go), so they exclude the handler's validation.

Can we handle all metrics in gRPC handlers to align?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The split is intentional to some extent. The workflow owns the labels, so recording in handlers would drop those dims from exactly the failure datapoints they're for. But I agree about this not being fully consistent as validation failures currently count create/delete and not the workflow operations.

I'd rather fix that by moving the create/delete recording after validation, so all five operations measure a validated operation, and keep malformed requests visible in rpc.server.call.duration. Wdyt?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sg

@baizhenyu Tim Bai (baizhenyu) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The WasRunning skip in the latest commit is a good fix, and reusing the existing signal means the metric and the resumed return value cannot disagree.

I have left some questions inline about the metric definitions. I would like to settle these before we build dashboards on top of them.

Comment thread cmd/ateapi/internal/controlapi/workflow_resume.go Outdated
Comment thread cmd/ateapi/internal/controlapi/workflow_resume.go
Comment thread cmd/ateapi/internal/controlapi/metrics.go Outdated
Comment thread docs/observability.md Outdated
Comment thread cmd/ateapi/internal/controlapi/metrics.go
Comment thread docs/observability.md Outdated
Comment thread cmd/ateapi/internal/controlapi/delete_actor.go Outdated
@JeffLuoo
Jeff Luo (JeffLuoo) merged commit 6f2b217 into agent-substrate:main Aug 4, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants